fix(studio-server): stop manual-edits seek wrapper from retaining stale applyManifest closures - #2909
fix(studio-server): stop manual-edits seek wrapper from retaining stale applyManifest closures#2909felipecaldas wants to merge 2 commits into
Conversation
…le applyManifest closures wrapFunction/wrapSeekFunctions wrap __hf.seek/__player.renderSeek to reapply studio manual edits (translate/rotate/box-size) after every seek. Each wrapper closed directly over that generation's applyManifest — the whole runtime script is re-injected on every studio edit, so if a wrapper from an earlier generation is still active (isWrapped finds it already marked and skips re-wrapping, or it's left in place by another seek-wrapping subsystem such as studioPositionSeekReapplyRuntime's installSeekTrap), it kept calling its own stale applyManifest — and everything that closure retained (manifestEdits, resolveTarget, every apply* helper) — for the rest of the session instead of the current one. Route the wrapper through the existing __hfStudioManualEditsApply window slot (already kept current on every re-run) instead of the closed-over reference, so a wrapper always calls live logic and a stale generation's closure becomes collectible once the slot is overwritten. No change to when wrapping happens — confirmed via a DevTools retainer trace during a long Studio editing session that this closure chain was what stayed alive across repeated re-runs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
miguel-heygen
left a comment
There was a problem hiding this comment.
Reviewed exact head 50db827521d0dbbb915aa59f22efbb3c639c85f8. The live-slot dispatch is behaviorally useful, but I found two blockers and one required regression:
-
P1 — the heap-retention edge is still present.
wrappedSeekis still created inside the samestudioManualEditsRenderRuntimeinvocation asapplyManifest. In V8, retaining that wrapper retains the shared function context, including the siblingapplyManifestbinding and itsmanifestEdits/ resolver / helper graph. Reading the current callback throughwindow.__hfStudioManualEditsApplychanges which function executes, but does not sever the old wrapper → old runtime-context edge.I reproduced this against the exact built helper with Node/V8 GC: after generation B overwrote the global slot, generation A’s wrapper remained installed and a
WeakRefto generation A’s parsed manifest still remained alive after repeated forced collections. Move wrapper construction to a separately scoped serialized factory that only closes over the original seek and window/live slot; another nested factory inside this runtime is not enough. Recheck the retainer path after multiple injections. -
P1 — drop the unrelated release commit. This PR includes
33eecd5b9 chore: release v0.7.86, which changes 18 release/version/changelog files. Please rebuild the branch from currentorigin/mainand cherry-pick only the bug-fix commit. That also brings in the five commits currently on main but absent from this branch. The release update should remain separate from this bug fix. -
P2 — add the exact two-generation regression. The existing seek test injects one generation only; its reassignment path is still same-generation, so reverting these three calls to direct
applyManifest()would pass. Add a test that installs manifest A, retains its marked wrapper, injects manifest B into the same window, invokes the retained wrapper, and proves B is applied. Cover reassignment after B and__player.renderSeekif applicable. The memory claim also needs a V8/browser retainer or GC-focused check because the behavioral assertion alone will not detect the shared-context leak.
git diff --check passes. The targeted local test command could not collect in this review worktree because happy-dom is absent, and this exact head currently exposes only the WIP check on GitHub; no substantive build/test/lint checks are attached yet.
jrusso1020
left a comment
There was a problem hiding this comment.
Additive review. The existing review already requested changes covering the release commit, the shared-closure-context retention, and the missing two-generation test, and I independently reached the same conclusion on the retention point, so I am not restating those. Three things that review does not cover, one of which changes who is being asked to do what.
First, the release files are not yours
Worth saying plainly because the ask reads differently once you know: this branch contains two commits, and the 18 version/changelog/release files all belong to the second one, chore: release v0.7.86, which you did not write. Your own commit is exactly one file, 26 additions and 3 deletions, confined to the helper. You already did the thing a reviewer would normally have to ask for.
What happened is that the release commit is not reachable from main, so a diff against main attributes its files to your branch. v0.7.86 is already published as the latest release from that commit, while main itself still reads 0.7.85 in the package manifests and has no releases/v0.7.86.md. So the release did get cut and tagged, but that commit never landed on main.
Two consequences:
- The remedy is a rebase onto current
main, not a change in how you scope your work. Rebasing drops the release commit from the diff and leaves your single-file fix. - Merging as-is would also add a
releases/v0.7.86.mdand a0.7.86bump tomaindescribing this leak fix, while the publishedv0.7.86describes an unrelated set of changes. That is worth sorting out on our side independently of this PR, and it is not something you should have to work around.
Second, and this is the part I would prioritize: the wrapper chain grows one link per injected generation
This is the root cause underneath the retention finding, and I do not think it has been named yet. It explains why a stale wrapper is reachable at all.
There are two independent seek-wrapping runtimes in this file, and each has its own marker property: manualEditsRenderScript.ts:39 uses __hfStudioPositionSeekReapplyWrapped, manualEditsRenderScript.ts:393 uses __hfStudioManualEditsWrapped. Each runtime's isWrapped only recognizes its own. So once the position-seek runtime wraps a seek that the manual-edits runtime already wrapped, the manual-edits runtime no longer recognizes it, and wrapFunction (:709-712) stops taking the early-return branch and wraps again on every subsequent generation.
Measured on the real script strings, injecting the position-seek runtime once (as the compiler does) and the manual-edits runtime N times, then calling __hf.seek(1) exactly once and counting how many wrappers it reaches:
generations= 1 -> wrappers reached per ONE seek: 1 base seek calls: 1
generations= 2 -> wrappers reached per ONE seek: 2 base seek calls: 1
generations= 3 -> wrappers reached per ONE seek: 3 base seek calls: 1
generations= 5 -> wrappers reached per ONE seek: 5 base seek calls: 1
generations= 8 -> wrappers reached per ONE seek: 8 base seek calls: 1
Linear and unbounded, one new wrapper per edit, each retaining its own generation's scope. That is the reachable-wrapper end of the retention chain, so it is why the shared-context problem bites rather than being theoretical.
A caveat on reproducing this, because it cost me a wrong reading first: the position-seek runtime early-returns unless the document carries one of the data-hf-studio-path-offset|box-size|rotation="true" markers or data-hf-studio-motion (:41-47), and the compiler only injects it when those are present (packages/producer/src/services/htmlCompiler.ts:1898-1903). With a fixture missing those markers the runtime never installs, only one wrapper is ever created, and the growth is invisible. My first probe did exactly that and showed a flat 1 at every generation count. Any test for this needs those attributes on the fixture, or it will pass while measuring nothing.
The implication for your fix: routing through the live slot is necessary but not sufficient. It is the right half, because it is what lets one long-lived wrapper serve every future generation. The missing half is making the manual-edits isWrapped (:699-700) recognize a seek already wrapped by the sibling runtime, so no second wrapper is ever created. With both halves, one wrapper exists for the life of the page, it always calls current logic through the slot, and generations 2..N retain nothing.
Also worth noting as a consequence rather than a defect: at N generations a single seek performs N full applyManifest passes (:648-661) over every edit. That redundancy predates this PR, since each stale wrapper previously called its own stale copy, so you have not made it worse. Your change makes those N passes idempotent and correct instead of stale, which is a real improvement. Capping the chain removes the redundancy entirely.
Third, the sibling runtime has the same shape and is left as-is
wrapFn at :285-303 calls reapplyAll() directly from the wrapper closure, exactly the pattern you replaced on the manual-edits side, and it retains that runtime's reapplyAll plus everything it closes over. It is less severe in practice because that runtime is injected once at compile time rather than per edit, so it does not multiply per generation. Not asking you to fix it here, and I would not expand this PR's scope to cover it. Flagging it so it is a known sibling rather than something rediscovered later.
What is good here
- The scoping call is right and I would keep making it. You found three leak sources and shipped one, leaving the hidden-
<img>React Fiber and the GSAP global out. That is the correct instinct even though it makes the PR look smaller than the work behind it. :662-680records why the indirection exists and what it is defending against, including naming the sibling runtime as a same-shaped risk. Rationale that explains a non-obvious construct is what stops the next person reverting it as an unnecessary layer.- The
?.()on every slot call is the right defensive shape, since a stale wrapper can outlive a generation that returned early at:416on an empty manifest, which would leave the slot unset. - Evidence quality on the report is well above average for a leak: two snapshots, a delta, and a retainer trace rather than an impression.
Verification
- Full file read at head
50db82752, not just the diff. Both probes below were run against the real exported script strings, and the tree was restored clean afterward. - Retention, measured rather than reasoned. I first built a probe that reported "retained" for both the pre-fix and post-fix shapes, but its sensitivity floor also reported "retained" for a case that must free, so it proved nothing. The cause was that a
WeakReftarget stays alive for the remainder of the job in which it is created. After adding a macrotask boundary before collection, the floor cases free correctly and the pre-fix and post-fix shapes are still identical. Same conclusion the existing review reached; noting the failure mode because a probe that cannot detect freeing will happily report a leak that is not there, and vice versa. - Test sensitivity. Reverting the fix, restoring the direct
applyManifest()call at all three sites, leaves all 14 tests in the helper's suite passing. So nothing currently pins this behavior in either direction. Agrees with the two-generation gap already raised. - CI has not run, and the reason is on our side. All seven workflows at this head report
action_required, which is GitHub holding workflow approval for a first-time contributor rather than anything wrong with your branch. The only reported check is theWIPmarketplace one. So there is no CI signal here yet, green or red, and nothing for you to chase. A maintainer needs to authorize the run. - Not verified by me: the object-count delta and retainer trace from your snapshots. Those are author-reported, and the code reading is consistent with them.
Verdict: COMMENT (additive to the existing changes-requested; not adding a second gate)
Reasoning: The runtime direction is right and the fix is a genuine correctness improvement, but the retention it claims is not severed yet, and the reason is the chain growing one wrapper per generation from the two runtimes not recognizing each other's markers. Rebase drops 18 files that are not yours.
— Rames Jusso
|
Supplemental call-graph finding after the review: P1 — this is not the runtime that owns the reported long interactive Studio session. The interactive preview path is That contradicts the new comment at lines 662–680 and means this patch cannot explain or fix the measured long-session INP/tab crash without an additional call path. Please trace the DevTools retainer root back to the actual interactive installation path and fix that owner, or document/reproduce the exact long-lived same-window path that executes this helper repeatedly. Two related lifecycle gaps also need coverage if this helper change remains:
These reinforce the need for a combined-runtime, multi-generation regression rather than the current single-generation tests. |
|
Correcting one framing in my review above, and sharpening the finding as a result. I had the lifecycle wrong. I described the wrapper multiplying "per injected generation" across an editing session, taking the code comment's "each studio edit / soft reload injects a fresh copy" at face value. That is true of the interactive Studio path, not of this helper. Every consumer of The multiplication is real, but it happens inside a single page load, which makes it worse rather than better. The re-wrap poll at Flat at 1 when the sibling runtime is absent, One thing that argues for your approach. The interactive path already does exactly what you are adding: it sets the slot and calls Which leaves the retention evidence pointing at a different file than the one you changed. If the snapshots came from a long editing session, the retained graph was most likely rooted in the interactive path rather than this helper, which is consistent with the ask to trace the retainer root before settling on a fix. Nothing here reduces the value of the change; the marker fix stands on the measurement above on its own. Verdict unchanged: COMMENT, additive. The measurement is independent of the earlier review's; where we overlap, we agree. — Rames Jusso |
|
Closing — this was opened before the fix was actually tested against a real Studio session. Apologies for the noise; will reopen (or open fresh) once verified. |
15 releases and 154 commits of upstream drift, taken in one merge rather than letting it compound. Measured cost: 25 of our 114 patched files overlapped upstream's 674, producing 10 real code hunks across 6 files (plus bun.lock, regenerated). Three carried patches are dropped because upstream now owns them: - TAB-792 (79e8858). Upstream heygen-com#3535 landed a logically identical start-based predicate for the local-vs-root-global timing convention. Taken verbatim so the file returns to upstream identity and stops conflicting here forever. - The heygen-com#3349 cherry-pick (0d1d683). Upstream refactored the same bound into `clampNativeMediaVolume`, which `withUnclampedVolume` also uses, so the two cannot drift. - The studio-server lint *route*. Upstream heygen-com#3393 runs whole-project lint first and only falls back to per-file for uncovered HTML — a superset of ours, and a better answer to the TAB-780/781 problem. The `helpers/projectLint.ts` helper STAYS: agent/providers.ts and agent/runtime.ts still import it. Patches kept, re-sited onto upstream's refactors: - PromptPreviewModal moved to its own file upstream and gained a focus trap, a dirty-draft close veto and copy-failure state. Our "Create with Agent" button and `registryItem` prop are ported onto that version rather than keeping our older in-file copy. - EditModal's agent-bridge handoff keeps its behaviour and adopts upstream's draft clearing. No copy-failure branch: openAgentBridge is a synchronous window event, not a clipboard write the browser can refuse. - PropertyPanelFlat's caption section composes with upstream's new audio-fx fallback. Verified on this branch: build exit 0; typecheck clean in core, studio and studio-server; lint 0 errors; core 2598 tests, studio 4604, studio-server 534, all passing with real summary lines. Fork invariants measured on the built dist — vendorRoute=3, sameOriginMotionPath=1, jsdelivrGsap=0, jsdelivrAll=0, __hfStudioManualEditsApply=6 (fork count, not upstream's 3), so TAB-697, TAB-746 and PR heygen-com#2909 all survived. Renders are NOT proven frame-stable: 674 upstream files changed and no render evidence was gathered here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem
Found via a real, long interactive Studio editing session that grew to a 145,592ms local INP and
eventually crashed the tab ("Render process gone"), well after ruling out environment/RAM causes.
Two DevTools heap snapshots ~82s apart showed +487,208 objects; a retainer trace on the top offender
(
PropTween, GSAP's internal tween-property object) rooted atwindow.__hfStudioManualEditsApply.wrapFunction/wrapSeekFunctionsinmanualEditsRenderScript.tswrap__hf.seek/__player.renderSeekso studio manual edits (translate/rotate/box-size) get reapplied after everyseek. Each installed wrapper closes directly over that generation's
applyManifest— but this wholeruntime script is re-injected fresh on every studio edit. If a wrapper from an earlier generation is
still the active one (
isWrappedfinds it already marked on the current seek function and skipsre-wrapping — by design, e.g. after a plain re-run with no underlying reassignment), it keeps calling
its own stale
applyManifestclosure, which retains everything it closed over(
manifestEdits,resolveTarget, everyapply*helper) for the rest of the session instead of thecurrent one. The same risk applies if another seek-wrapping subsystem in this file
(
studioPositionSeekReapplyRuntime'sinstallSeekTrap, which independently wraps the same twofunctions) leaves an older wrapper reachable.
Fix
Route the wrapper through the
__hfStudioManualEditsApplywindow slot — already kept current onevery re-run — instead of closing over
applyManifestdirectly. A wrapper now always calls livelogic, and a stale generation's
applyManifestclosure becomes collectible as soon as a newergeneration overwrites the slot, regardless of how long the wrapper function object itself stays
reachable. No change to when wrapping happens or the re-entrancy/reassignment-recovery behavior
already covered by the existing interval-polling test (
wrapSeekFunctionsre-wraps a genuinelyreplaced, unmarked seek function exactly as before).
Verified
bun test packages/studio-server/src/helpers/manualEditsRenderScript.test.ts packages/core/src/studio-api/helpers/manualEditsRenderScript.test.ts— 28/28 pass, including thereassignment-recovery case (external code replaces
__hf.seek; interval polling re-wraps it).tsc --noEmitclean for@hyperframes/studio-server.oxlintclean on the changed file.Related, not duplicated by this PR
window.__timelinesneighborhood (drag-pause neverresumes sub-composition timelines). Not the same code path, but same general area.
caption-*blocks registering timelines asynchronously, violating thedeterminism contract. Unrelated mechanism, same general subsystem.
__hfForceTimelineRebind) this fix's wrapperultimately runs under.
Two further leak sources were found via retainer traces during the same investigation but aren't
fixed here — filing a separate issue with the evidence rather than bundling unrelated, less-verified
changes into this PR:
<img>(class="hidden"), retained viablink::ThreadState's "Pendingactivities" — looks like asset/thumbnail image handling leaving pending decode state uncleaned.
TimelineLite(distinct fromwindow.__timelines), reached through nestedbound_thisclosures rooted in Studio's own UI code — plausibly its playhead/scrubber, not yetisolated to a specific file.
🤖 Generated with Claude Code